#!/usr/bin/env python3
"""
Script to identify docstring formatting issues in Python files.

Checks for:
1. Bullet lists (lines starting with *, -, +) without blank line before them
2. Numbered lists (lines starting with digits and .) without blank line before them
3. Code blocks (lines starting with >>>) without blank line before them
4. reStructuredText directives (lines starting with ..) without blank line before them

These are common reStructuredText/Sphinx formatting issues that can cause
documentation to render incorrectly.

The script attempts to avoid false positives by:
- Skipping content inside literal blocks (after :: markers)
- Ignoring items that follow Sphinx field markers (:param:, :Example:, etc.)
- Handling Python interactive session output (lines between >>> prompts)
- Recognizing indented continuations

Known limitations:
- May flag some valid trailing >>> prompts in code examples
- Line numbers are approximate (offset from docstring start)
- Some complex nested structures may not be handled perfectly

Usage:
  python check_docstring_formatting.py [directory]

If no directory is specified, defaults to ../python relative to this script.
"""

import os
import re
import ast
import sys
from pathlib import Path


def get_docstrings_from_file(filepath):
    """Extract all docstrings from a Python file with their line numbers."""
    try:
        with open(filepath, 'r', encoding='utf-8') as f:
            content = f.read()

        tree = ast.parse(content, filename=str(filepath))
        docstrings = []

        for node in ast.walk(tree):
            # Only check nodes that can have docstrings
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)):
                try:
                    docstring = ast.get_docstring(node, clean=False)
                    if docstring:
                        # Get the line number where the docstring starts
                        if isinstance(node, ast.Module):
                            # Module docstring is at the top
                            line_num = 1
                        else:
                            # For functions/classes, it's the first statement
                            line_num = node.body[0].lineno if node.body else node.lineno

                        docstrings.append((line_num, docstring, type(node).__name__))
                except:
                    # Skip if we can't get the docstring
                    pass

        return docstrings
    except Exception as e:
        # Only show actual parse errors, not docstring extraction issues
        if "parsing" in str(e).lower() or "syntax" in str(e).lower():
            print(f"Error parsing {filepath}: {e}", file=sys.stderr)
        return []


def check_docstring_formatting(docstring):
    """
    Check for formatting issues in a docstring.

    Returns a list of (line_offset, issue_description) tuples.
    """
    issues = []
    lines = docstring.split('\n')

    # Patterns that should have a blank line before them
    patterns = [
        (r'^\s*[\*\-\+]\s+', 'bullet list item'),
        (r'^\s*\d+\.\s+', 'numbered list item'),
        (r'^\s*>>>', 'code block'),
        (r'^\s*\.\.\s+', 'reStructuredText directive'),
    ]

    # Sphinx field patterns that can contain code blocks or lists
    sphinx_field_pattern = r'^\s*:[A-Za-z_][A-Za-z0-9_]*:'

    # Track if we're in a literal block (started by ::)
    in_literal_block = False
    literal_block_indent = 0

    for i, line in enumerate(lines):
        # Skip the first line (always part of the opening)
        if i == 0:
            continue

        current_indent = len(line) - len(line.lstrip())
        stripped = line.strip()

        # Check if previous line ended with :: (literal block marker)
        if i > 0:
            prev_line = lines[i - 1]
            if prev_line.rstrip().endswith('::'):
                in_literal_block = True
                literal_block_indent = len(prev_line) - len(prev_line.lstrip())

        # If we're in a literal block and dedented, we're out
        if in_literal_block and stripped and current_indent <= literal_block_indent:
            in_literal_block = False

        # Skip checks if we're inside a literal block
        if in_literal_block:
            continue

        # Check each pattern
        for pattern, description in patterns:
            if re.match(pattern, line):
                # Check if previous line is blank or also matches a list pattern
                prev_line = lines[i - 1] if i > 0 else ''

                # If previous line is not blank
                if prev_line.strip() != '':
                    # Check if previous line is also a list item (which is OK)
                    is_prev_list = any(re.match(p[0], prev_line) for p in patterns)

                    # Check if previous line is a Sphinx field (like :Example:, :param:, etc.)
                    is_sphinx_field = re.match(sphinx_field_pattern, prev_line)

                    # Check if we're indented under a previous section
                    # If current line is more indented than previous non-blank line, it's likely continuation
                    prev_indent = len(prev_line) - len(prev_line.lstrip())
                    is_indented_continuation = current_indent > prev_indent

                    # Special case for code blocks (>>>):
                    if description == 'code block':
                        # In Python interactive sessions, >>> prompts after output or continuations are normal
                        # Skip if: previous line is >>> or ..., OR both lines are indented (in code example)
                        if (prev_line.strip().startswith('...') or
                            prev_line.strip().startswith('>>>') or
                            (prev_indent > 0)):  # Both lines indented = inside code example
                            # Don't report this as an issue
                            break

                    # Special case for bullet/numbered lists:
                    # 1. Check if we're continuing a list (prev line is wrapped text from previous bullet)
                    # 2. Check if we're nested under another list item
                    is_nested_list = False
                    if description in ['bullet list item', 'numbered list item']:
                        # Look back to find context - skip blank lines
                        for j in range(i - 1, max(0, i - 10), -1):
                            check_line = lines[j]
                            if not check_line.strip():
                                continue  # Skip blank lines
                            check_indent = len(check_line) - len(check_line.lstrip())

                            # If we find a line at same indent that's also a list item, we're continuing a list
                            if check_indent == current_indent and any(re.match(p[0], check_line) for p in patterns):
                                is_nested_list = True  # This is a list continuation
                                break

                            # If we find a less-indented line that's also a list item, we're nested
                            if check_indent < current_indent and any(re.match(p[0], check_line) for p in patterns):
                                is_nested_list = True
                                break

                            # If we find a non-list line at current or less indent (intro text), stop looking
                            if check_indent <= current_indent:
                                break

                    if not is_prev_list and not is_sphinx_field and not is_indented_continuation and not is_nested_list:
                        issues.append((i + 1, f"{description} without blank line before it"))
                break  # Only report one issue per line

    return issues


def find_python_files(root_dir):
    """Find all Python files in the given directory."""
    root = Path(root_dir)
    return list(root.rglob('*.py'))


def main():
    # Default to checking the python directory relative to this script
    script_dir = Path(__file__).parent
    python_dir = script_dir.parent / 'python'

    if len(sys.argv) > 1:
        python_dir = Path(sys.argv[1])

    if not python_dir.exists():
        print(f"Error: Directory {python_dir} does not exist", file=sys.stderr)
        sys.exit(1)

    print(f"Checking Python files in: {python_dir}")
    print("=" * 80)

    files_with_issues = 0
    total_issues = 0

    for py_file in sorted(find_python_files(python_dir)):
        docstrings = get_docstrings_from_file(py_file)
        file_issues = []

        for doc_line_num, docstring, node_type in docstrings:
            issues = check_docstring_formatting(docstring)
            if issues:
                for line_offset, issue_desc in issues:
                    # Calculate absolute line number in file
                    # This is approximate since we don't have exact positions
                    abs_line = doc_line_num + line_offset
                    file_issues.append((abs_line, issue_desc, node_type))

        if file_issues:
            files_with_issues += 1
            total_issues += len(file_issues)

            # Make path relative to python_dir for cleaner output
            rel_path = py_file.relative_to(python_dir.parent)
            print(f"\n{rel_path}:")

            for line_num, issue_desc, node_type in sorted(file_issues):
                print(f"  Line ~{line_num} ({node_type}): {issue_desc}")

    print("\n" + "=" * 80)
    print(f"Summary: Found {total_issues} issues in {files_with_issues} files")

    return 0 if total_issues == 0 else 1


if __name__ == '__main__':
    sys.exit(main())
